5.03. Рекомендации по разработке на Java
Рекомендации по разработке на Java
1. Требования по именованию
1.1. Общие правила именования
Имена в Java должны точно отражать назначение сущности без избыточных уточнений. Каждый элемент языка использует соответствующую нотацию:
| Элемент языка | Нотация | Пример |
|---|---|---|
| Пакет | lowercase | com.example.service |
| Класс, запись (record) | PascalCase | UserService |
| Интерфейс | PascalCase | DataRepository |
| Перечисление (тип) | PascalCase | OrderStatus |
| Перечисление (значение) | UPPER_SNAKE_CASE | PENDING, COMPLETED |
| Константа (static final) | UPPER_SNAKE_CASE | MAX_RETRY_COUNT |
| Статическое поле | camelCase | instanceCounter |
| Приватное поле | camelCase | userRepository |
| Локальная переменная | camelCase | totalAmount |
| Метод | camelCase | calculateTotal |
| Параметр метода | camelCase | orderId |
| Типовой параметр | Одна заглавная буква | T, E, K, V |
Имена пакетов всегда пишутся строчными буквами без подчёркиваний. Используйте обратный доменный порядок для глобальной уникальности: ru.spirzen.project.module.
1.2. Именование классов и интерфейсов
Классы именуются существительными или словосочетаниями с существительным в конце. Интерфейсы также именуются существительными, избегая префиксов вроде I или Interface:
// Хорошо
public class OrderProcessor { }
public interface PaymentGateway { }
// Плохо
public class OrderProcessingService { } // избыточное слово "Service"
public interface IPaymentProvider { } // префикс I
Для классов-исключений используйте суффикс Exception:
public class ValidationException extends RuntimeException { }
public class ResourceNotFoundException extends Exception { }
1.3. Именование методов
Методы именуются глаголами или глагольными словосочетаниями. Для методов-предикатов используйте префиксы is, has, can, should:
public boolean isActive() { }
public boolean hasPermission(String resource) { }
public void submitOrder(Order order) { }
public Order findOrderById(Long id) { }
Методы, возвращающие новый объект на основе текущего, именуются с префиксом to или as:
public String toString() { }
public BigDecimal toBigDecimal() { }
Методы, изменяющие состояние объекта и возвращающие самого себя для цепочек вызовов, именуются глаголами без специальных префиксов:
public OrderBuilder withCustomer(Customer customer) { }
1.4. Именование переменных и параметров
Имена должны быть достаточно длинными для однозначного понимания, но не избыточными. Избегайте однобуквенных имён за исключением стандартных случаев:
// Допустимо для циклов
for (int i = 0; i < items.size(); i++) { }
// Допустимо для координат и математических переменных
double x = point.getX();
double y = point.getY();
// Хорошо
List<User> activeUsers = userRepository.findActive();
int retryCount = 0;
// Плохо
List<User> au = userRepository.findActive(); // непонятная аббревиатура
int rc = 0; // неочевидное сокращение
Для коллекций используйте множественное число или суффиксы List, Set, Map при необходимости уточнения типа:
List<Order> orders = orderRepository.findAll();
Map<Long, User> userMap = users.stream()
.collect(Collectors.toMap(User::getId, Function.identity()));
2. Требования по оформлению кода
2.1. Отступы и пробелы
Используйте 4 пробела для отступов. Не применяйте символы табуляции. Между операторами и операндами ставьте один пробел:
// Хорошо
int sum = a + b;
if (value > threshold) {
process(value);
}
// Плохо
int sum=a+b;
if(value>threshold){
process(value);
}
Не ставьте пробелы:
- После открывающей круглой скобки и перед закрывающей
- Перед запятой и точкой с запятой
- После открывающей угловой скобки типового параметра и перед закрывающей
// Хорошо
List<String> names = new ArrayList<>();
method(param1, param2);
if (condition) { }
// Плохо
List< String > names = new ArrayList< >();
method( param1 , param2 );
if ( condition ) { }
2.2. Фигурные скобки и стиль Олмана
Всегда используйте фигурные скобки даже для однострочных блоков. Применяйте стиль Олмана: открывающая скобка на новой строке с тем же отступом, что и управляющая конструкция:
// Хорошо
if (condition) {
doSomething();
} else {
doSomethingElse();
}
for (int i = 0; i < 10; i++) {
process(i);
}
// Плохо
if (condition)
doSomething();
if (condition) doSomething();
if (condition) {
doSomething(); } // закрывающая скобка не на новой строке
2.3. Длина строк и переносы
Максимальная длина строки — 120 символов. При переносе аргументов метода или элементов цепочки вызовов каждый элемент размещайте на новой строке с дополнительным отступом:
// Хорошо
User user = userRepository.findById(userId)
.orElseThrow(() -> new UserNotFoundException(userId));
Order order = new OrderBuilder()
.withCustomerId(customerId)
.withItems(items)
.withShippingAddress(address)
.build();
// Плохо
User user = userRepository.findById(userId).orElseThrow(() -> new UserNotFoundException(userId));
Для длинных условий в if переносите каждую часть условия на новую строку с выравниванием операторов:
// Хорошо
if (order.getStatus() == OrderStatus.PENDING
&& order.getAmount().compareTo(BigDecimal.ZERO) > 0
&& order.getCustomer().isActive()) {
processOrder(order);
}
2.4. Пустые строки
Разделяйте логические блоки кода одной пустой строкой:
- Между методами класса
- Между полями и конструктором
- Между различными секциями внутри метода (объявление переменных, основная логика, возврат результата)
- Перед и после блоков
try-catch - Между импортируемыми пакетами разных организаций
Не оставляйте несколько пустых строк подряд и не ставьте пустые строки в начале или конце блока кода.
3. Структура проекта и организация файлов
3.1. Стандартная структура каталогов Maven/Gradle
src/
├── main/
│ ├── java/
│ │ └── com/
│ │ └── example/
│ │ ├── application/ # Слой приложения (сервисы, команды)
│ │ ├── domain/ # Доменная модель (сущности, агрегаты)
│ │ ├── infrastructure/ # Инфраструктурные компоненты (БД, внешние сервисы)
│ │ └── interfaces/ # Внешние интерфейсы (контроллеры, адаптеры)
│ └── resources/
│ ├── application.properties
│ └── db/migration/ # Миграции базы данных
└── test/
├── java/
│ └── com/
│ └── example/
│ ├── application/
│ ├── domain/
│ └── infrastructure/
└── resources/
└── application-test.properties
3.2. Организация классов внутри пакетов
Внутри каждого функционального пакета группируйте классы по назначению:
domain/
├── model/ # Сущности и агрегаты
├── valueobject/ # Объекты-значения
├── repository/ # Интерфейсы репозиториев
└── event/ # События домена
Избегайте создания пакетов с именами вроде utils, helpers, common. Такие пакеты становятся сборниками несвязанной функциональности. Вместо этого размещайте утилитарные классы в пакетах, соответствующих их доменной области.
3.3. Файлы и их содержимое
Каждый файл .java должен содержать только один публичный класс с именем, совпадающим с именем файла. Допускается размещение нескольких непубличных классов в одном файле, если они тесно связаны и используются только внутри основного класса.
Располагайте элементы класса в следующем порядке:
- Константы (
public static final) - Статические поля
- Поля экземпляра
- Конструкторы
- Методы жизненного цикла (например,
@PostConstruct) - Публичные методы
- Защищённые методы
- Пакетные методы
- Приватные методы
- Вложенные классы и интерфейсы
4. Проектирование классов и интерфейсов
4.1. Принцип единственной ответственности
Каждый класс должен иметь одну и только одну причину для изменения. Класс, выполняющий несколько несвязанных задач, нарушает этот принцип:
// Плохо: класс управляет заказом и отправляет уведомления
public class Order {
private Long id;
private List<OrderItem> items;
public void submit() {
// логика подтверждения заказа
}
public void sendEmailNotification() {
// логика отправки email
}
public void generatePdfInvoice() {
// логика генерации PDF
}
}
// Хорошо: разделение ответственности
public class Order {
private Long id;
private List<OrderItem> items;
public void submit() {
// логика подтверждения заказа
}
}
public class NotificationService {
public void sendOrderConfirmationEmail(Order order) {
// логика отправки email
}
}
public class InvoiceGenerator {
public byte[] generatePdfInvoice(Order order) {
// логика генерации PDF
}
}
4.2. Инкапсуляция и сокрытие реализации
Поля класса должны быть приватными. Доступ к состоянию объекта предоставляется через методы. Избегайте создания публичных полей и избыточных геттеров/сеттеров:
// Плохо: нарушение инкапсуляции
public class User {
public String email; // публичное поле
private String password;
public String getPassword() { // прямой доступ к чувствительному полю
return password;
}
}
// Хорошо: контролируемый доступ
public class User {
private final String email;
private String passwordHash;
public User(String email, String password) {
validateEmail(email);
this.email = email.toLowerCase().trim();
this.passwordHash = hashPassword(password);
}
public String getEmail() {
return email;
}
public boolean verifyPassword(String password) {
return checkPasswordHash(password, passwordHash);
}
// Нет сеттера для пароля — изменение происходит через отдельный метод
public void changePassword(String oldPassword, String newPassword) {
if (!verifyPassword(oldPassword)) {
throw new SecurityException("Invalid current password");
}
this.passwordHash = hashPassword(newPassword);
}
}
4.3. Неизменяемость
Предпочитайте неизменяемые классы там, где это возможно. Неизменяемые объекты проще в использовании, потокобезопасны и защищены от побочных эффектов:
// Хорошо: неизменяемый класс
public final class Money {
private final BigDecimal amount;
private final Currency currency;
public Money(BigDecimal amount, Currency currency) {
this.amount = amount.setScale(2, RoundingMode.HALF_UP);
this.currency = Objects.requireNonNull(currency);
}
public Money add(Money other) {
if (!this.currency.equals(other.currency)) {
throw new IllegalArgumentException("Currencies must match");
}
return new Money(this.amount.add(other.amount), this.currency);
}
public BigDecimal getAmount() {
return amount;
}
public Currency getCurrency() {
return currency;
}
}
Для создания неизменяемых классов:
- Объявляйте класс как
final - Делайте все поля
private final - Не предоставляйте сеттеры
- Защищайте изменяемые компоненты при передаче (копируйте коллекции)
- Используйте
recordsв современных версиях Java для простых неизменяемых структур данных
4.4. Интерфейсы и абстракции
Интерфейсы должны быть узкоспециализированными и ориентированными на клиента. Избегайте создания «жирных» интерфейсов с множеством методов, которые клиенты не используют:
// Плохо: жирный интерфейс
public interface Worker {
void work();
void eat();
void sleep();
void attendMeeting();
void writeReport();
}
// Хорошо: разделение интерфейсов
public interface Workable {
void work();
}
public interface Breakable {
void takeBreak();
}
public interface Reportable {
void writeReport();
}
public class Programmer implements Workable, Breakable, Reportable {
public void work() { /* реализация */ }
public void takeBreak() { /* реализация */ }
public void writeReport() { /* реализация */ }
}
public class Robot implements Workable {
public void work() { /* реализация */ }
// Роботу не нужно брать перерывы или писать отчёты
}
Предпочитайте зависимости от интерфейсов, а не от конкретных классов. Это упрощает тестирование и замену реализаций.
5. Проектирование методов и функций
5.1. Длина и сложность методов
Метод должен умещаться на один экран без прокрутки (рекомендуемая длина — до 20 строк). Методы с высокой цикломатической сложностью (>10) следует декомпозировать:
// Плохо: длинный метод со сложной логикой
public void processOrder(Order order) {
if (order == null) {
throw new IllegalArgumentException("Order cannot be null");
}
if (order.getItems().isEmpty()) {
throw new OrderValidationException("Order must have items");
}
BigDecimal total = BigDecimal.ZERO;
for (OrderItem item : order.getItems()) {
if (item.getQuantity() <= 0) {
throw new OrderValidationException("Invalid quantity for item " + item.getId());
}
Product product = productRepository.findById(item.getProductId())
.orElseThrow(() -> new ProductNotFoundException(item.getProductId()));
if (product.getStock() < item.getQuantity()) {
throw new InsufficientStockException(product.getId(), product.getStock(), item.getQuantity());
}
BigDecimal itemTotal = product.getPrice().multiply(BigDecimal.valueOf(item.getQuantity()));
total = total.add(itemTotal);
}
if (order.getDiscountCode() != null) {
Discount discount = discountRepository.findByCode(order.getDiscountCode());
if (discount != null && discount.isValid() && total.compareTo(discount.getMinOrderAmount()) >= 0) {
BigDecimal discountAmount = total.multiply(discount.getPercentage()).divide(BigDecimal.valueOf(100));
total = total.subtract(discountAmount);
order.setDiscountAmount(discountAmount);
}
}
order.setTotal(total);
order.setStatus(OrderStatus.PROCESSING);
orderRepository.save(order);
inventoryService.reserveStock(order.getItems());
notificationService.sendOrderConfirmation(order.getCustomerEmail(), order.getId());
}
// Хорошо: декомпозированный метод
public void processOrder(Order order) {
validateOrder(order);
calculateOrderTotal(order);
applyDiscountIfApplicable(order);
updateOrderStatus(order);
reserveInventory(order);
sendConfirmation(order);
}
private void validateOrder(Order order) {
if (order == null) {
throw new IllegalArgumentException("Order cannot be null");
}
if (order.getItems().isEmpty()) {
throw new OrderValidationException("Order must have items");
}
validateOrderItems(order.getItems());
}
private void validateOrderItems(List<OrderItem> items) {
for (OrderItem item : items) {
validateOrderItem(item);
checkProductAvailability(item);
}
}
// ... остальные методы
5.2. Количество параметров
Метод должен принимать не более 3-4 параметров. При необходимости передачи большего количества данных используйте объекты-параметры или строители:
// Плохо: метод с большим количеством параметров
public User createUser(String email, String password, String firstName,
String lastName, String phone, String address,
LocalDate birthDate, String nationality) {
// реализация
}
// Хорошо: объект-параметр
public class UserRegistrationRequest {
private final String email;
private final String password;
private final String firstName;
private final String lastName;
private final String phone;
private final String address;
private final LocalDate birthDate;
private final String nationality;
// конструктор, геттеры
}
public User createUser(UserRegistrationRequest request) {
// реализация
}
// Альтернатива: строитель
public User createUser(UserBuilder builder) {
// реализация
}
// Использование
User user = createUser(new UserBuilder()
.withEmail("user@example.com")
.withPassword("secure123")
.withFirstName("John")
// ... остальные параметры
.build());
5.3. Побочные эффекты
Методы должны иметь предсказуемое поведение. Методы с побочными эффектами должны быть чётко идентифицируемы по имени:
// Плохо: метод выглядит как геттер, но изменяет состояние
public List<Order> getOrders() {
if (orders == null) {
orders = orderRepository.findByUserId(userId);
}
return orders;
}
// Хорошо: метод с побочным эффектом имеет соответствующее имя
public List<Order> loadOrders() {
if (orders == null) {
orders = orderRepository.findByUserId(userId);
}
return orders;
}
// Ещё лучше: разделить получение и загрузку
public List<Order> getOrders() {
return orders; // может вернуть null или пустой список
}
public void loadOrders() {
this.orders = orderRepository.findByUserId(userId);
}
6. Работа с исключениями
6.1. Использование исключений по назначению
Исключения предназначены для обработки исключительных ситуаций, а не для управления потоком выполнения программы:
// Плохо: использование исключений для управления потоком
try {
User user = userRepository.findById(userId)
.orElseThrow(() -> new UserNotFoundException(userId));
processUser(user);
} catch (UserNotFoundException e) {
// обработка отсутствия пользователя
createUserDefaultProfile(userId);
}
// Хорошо: проверка условия перед действием
Optional<User> userOptional = userRepository.findById(userId);
if (userOptional.isPresent()) {
processUser(userOptional.get());
} else {
createUserDefaultProfile(userId);
}
6.2. Типы исключений
Предпочитайте проверяемые исключения (checked exceptions) для ситуаций, которые вызывающий код может и должен обработать. Используйте непроверяемые исключения (unchecked exceptions) для программных ошибок и условий, которые вызывающий код не может разумно обработать:
// Проверяемое исключение: вызывающий код может обработать недостаток средств
public void transferMoney(Account from, Account to, BigDecimal amount)
throws InsufficientFundsException {
if (from.getBalance().compareTo(amount) < 0) {
throw new InsufficientFundsException(from.getId(), amount);
}
// выполнение перевода
}
// Непроверяемое исключение: программная ошибка, которую нельзя обработать
public void processPayment(Payment payment) {
if (payment == null) {
throw new IllegalArgumentException("Payment cannot be null");
}
// обработка платежа
}
Создавайте специализированные типы исключений вместо использования общих классов вроде Exception или RuntimeException. Это улучшает читаемость кода и позволяет точно обрабатывать различные ошибочные ситуации.
6.3. Обработка исключений
Никогда не игнорируйте исключения. Даже если вы не можете обработать ошибку на текущем уровне, запишите её в лог:
// Плохо: пустой catch-блок
try {
sendNotification(user);
} catch (NotificationException e) {
// ничего не делаем
}
// Хорошо: логирование исключения
try {
sendNotification(user);
} catch (NotificationException e) {
logger.warn("Failed to send notification to user {}", user.getId(), e);
}
Избегайте перехвата общих исключений (Exception, Throwable) на уровне отдельных методов. Перехватывайте только те типы исключений, которые вы можете корректно обработать. Общие исключения следует перехватывать только на самых верхних уровнях приложения (контроллеры, точки входа).
При логировании исключений всегда используйте метод, который записывает полный стек вызовов:
// Хорошо
logger.error("Processing failed for order {}", orderId, exception);
// Плохо: теряется стек вызовов
logger.error("Processing failed for order {}: {}", orderId, exception.getMessage());
6.4. Освобождение ресурсов
Всегда освобождайте ресурсы в блоке finally или используйте конструкцию try-with-resources для автоматического закрытия:
// Хорошо: try-with-resources
try (Connection connection = dataSource.getConnection();
PreparedStatement statement = connection.prepareStatement(SQL)) {
statement.setLong(1, userId);
try (ResultSet resultSet = statement.executeQuery()) {
while (resultSet.next()) {
// обработка результата
}
}
} catch (SQLException e) {
throw new DataAccessException("Failed to load user", e);
}
// Плохо: ресурсы могут не освободиться при возникновении исключения
Connection connection = null;
try {
connection = dataSource.getConnection();
// работа с соединением
} catch (SQLException e) {
// обработка ошибки
} finally {
if (connection != null) {
try {
connection.close(); // может выбросить исключение
} catch (SQLException e) {
logger.warn("Failed to close connection", e);
}
}
}
7. Работа с коллекциями и потоками
7.1. Выбор подходящего типа коллекции
Используйте наиболее подходящий тип коллекции для конкретной задачи:
ArrayList— когда важна производительность доступа по индексу и порядок элементовLinkedList— когда важны частые вставки/удаления в начало или середину спискаHashSet— когда важна скорость проверки наличия элемента и порядок не имеет значенияLinkedHashSet— когда важна скорость проверки наличия и нужно сохранить порядок вставкиTreeSet— когда элементы должны быть отсортированыHashMap— стандартный выбор для ассоциативных массивовLinkedHashMap— когда важен порядок вставки ключейTreeMap— когда ключи должны быть отсортированы
Возвращайте из методов интерфейсы коллекций, а не конкретные реализации:
// Хорошо
public List<User> findActiveUsers() {
return new ArrayList<>(activeUsers);
}
// Плохо
public ArrayList<User> findActiveUsers() {
return new ArrayList<>(activeUsers);
}
7.2. Потоки (Streams API)
Используйте Streams API для обработки коллекций, когда это улучшает читаемость кода. Избегайте чрезмерно сложных цепочек операций:
// Хорошо: простая и понятная цепочка
List<String> activeUserEmails = users.stream()
.filter(User::isActive)
.map(User::getEmail)
.collect(Collectors.toList());
// Плохо: излишне сложная цепочка
List<Order> orders = customers.stream()
.filter(c -> c.getOrders() != null)
.flatMap(c -> c.getOrders().stream())
.filter(o -> o.getStatus() == OrderStatus.COMPLETED)
.filter(o -> o.getCompletionDate() != null)
.filter(o -> o.getCompletionDate().isAfter(LocalDate.now().minusMonths(1)))
.sorted(Comparator.comparing(Order::getTotalAmount).reversed())
.limit(10)
.collect(Collectors.toList());
Для сложных преобразований разбивайте логику на несколько этапов с промежуточными переменными с осмысленными именами.
7.3. Избегание модификации коллекций во время итерации
Никогда не модифицируйте коллекцию напрямую во время итерации по ней с помощью for-each:
// Плохо: ConcurrentModificationException
for (User user : users) {
if (!user.isActive()) {
users.remove(user); // ошибка во время выполнения
}
}
// Хорошо: использование итератора
Iterator<User> iterator = users.iterator();
while (iterator.hasNext()) {
User user = iterator.next();
if (!user.isActive()) {
iterator.remove();
}
}
// Альтернатива: создание новой коллекции
List<User> activeUsers = users.stream()
.filter(User::isActive)
.collect(Collectors.toList());
8. Асинхронное программирование
8.1. Выбор подходящей модели
Для асинхронных операций в Java используйте:
CompletableFuture— для композиции асинхронных операцийExecutorService— для управления пулом потоков- Реактивные библиотеки (Project Reactor, RxJava) — для систем с высокой нагрузкой и потоковой обработкой данных
// Пример использования CompletableFuture
public CompletableFuture<Order> processOrderAsync(OrderRequest request) {
return CompletableFuture.supplyAsync(() -> validateOrder(request), executor)
.thenCompose(validatedOrder -> reserveInventoryAsync(validatedOrder))
.thenCompose(orderWithInventory -> chargePaymentAsync(orderWithInventory))
.thenApply(orderWithPayment -> finalizeOrder(orderWithPayment))
.exceptionally(ex -> handleOrderProcessingFailure(request, ex));
}
8.2. Обработка ошибок в асинхронном коде
Всегда обрабатывайте ошибки в асинхронных цепочках. Не оставляйте CompletableFuture без обработки исключений:
// Плохо: исключение может быть потеряно
CompletableFuture.supplyAsync(() -> fetchData())
.thenAccept(data -> processData(data));
// Если fetchData выбросит исключение, оно не будет обработано
// Хорошо: обработка ошибок
CompletableFuture.supplyAsync(() -> fetchData())
.thenAccept(data -> processData(data))
.exceptionally(ex -> {
logger.error("Failed to process data", ex);
return null;
});
8.3. Управление потоками
Всегда используйте управляемые пулы потоков вместо создания потоков напрямую. Настройте размер пула в соответствии с характеристиками задач (CPU-bound или I/O-bound):
// Для CPU-bound задач: количество потоков ≈ количество ядер
int parallelism = Runtime.getRuntime().availableProcessors();
ExecutorService cpuBoundExecutor = Executors.newFixedThreadPool(parallelism);
// Для I/O-bound задач: больше потоков, так как они часто ждут
ExecutorService ioBoundExecutor = Executors.newFixedThreadPool(parallelism * 2);
Всегда корректно завершайте исполнители при остановке приложения:
// Graceful shutdown
executor.shutdown();
try {
if (!executor.awaitTermination(60, TimeUnit.SECONDS)) {
executor.shutdownNow();
}
} catch (InterruptedException e) {
executor.shutdownNow();
Thread.currentThread().interrupt();
}
9. Комментарии и документация
9.1. Комментарии должны объяснять «почему», а не «что»
Комментарии предназначены для объяснения причин принятия решений, а не для описания того, что делает код. Хорошо написанный код сам по себе объясняет, что происходит. Комментарии нужны для контекста:
// Плохо: комментарий описывает очевидное
// Увеличиваем счётчик на 1
counter++;
// Хорошо: комментарий объясняет причину
// Счётчик увеличивается здесь, а не в конце цикла,
// чтобы избежать пропуска первого элемента при условии
counter++;
9.2. Javadoc для публичного API
Все публичные классы, методы и поля должны иметь документацию в формате Javadoc. Это особенно важно для библиотек и модулей, которые используются другими разработчиками:
/**
* Представляет пользователя системы.
* <p>
* Объект пользователя содержит информацию о регистрации,
* правах доступа и предпочтениях. Пользователи создаются
* через {@link UserService#register} и загружаются через
* {@link UserRepository#findById}.
*/
public class User {
/**
* Уникальный идентификатор пользователя в системе.
* Генерируется автоматически при создании записи в базе данных.
*/
private final Long id;
/**
* Адрес электронной почты пользователя.
* Используется для аутентификации и отправки уведомлений.
* Должен быть уникальным в пределах системы.
*/
private final String email;
/**
* Проверяет, активен ли пользователь.
* <p>
* Неактивные пользователи не могут входить в систему,
* но их данные сохраняются для аудита и восстановления.
*
* @return true, если пользователь активен, false в противном случае
*/
public boolean isActive() {
return active;
}
}
9.3. Комментарии для сложной логики
Когда алгоритм или бизнес-логика сложны, добавляйте комментарии, объясняющие шаги:
public BigDecimal calculateTax(Order order) {
// Базовая ставка налога зависит от типа товара
BigDecimal baseRate = getBaseTaxRate(order.getProductType());
// Для товаров первой необходимости применяется пониженная ставка
if (isEssentialGood(order.getProductType())) {
baseRate = baseRate.multiply(ESSENTIAL_GOODS_DISCOUNT);
}
// Региональные надбавки применяются только для физических лиц
if (order.getCustomerType() == CustomerType.INDIVIDUAL) {
BigDecimal regionalSurcharge = getRegionalSurcharge(order.getRegion());
baseRate = baseRate.add(regionalSurcharge);
}
// Максимальная ставка налога ограничена законодательством
if (baseRate.compareTo(MAX_TAX_RATE) > 0) {
baseRate = MAX_TAX_RATE;
}
return order.getTotalAmount().multiply(baseRate);
}
9.4. TODO-комментарии
Используйте комментарии TODO для временных решений или известных проблем, но не оставляйте их надолго:
// TODO: Заменить на кэширование после реализации механизма инвалидации
List<Product> products = productRepository.findAll();
// TODO: Удалить после миграции на новую версию API (deadline: 2026-06-30)
@Deprecated
public void oldMethod() { }
9.5. Избегайте закомментированного кода
Никогда не оставляйте закомментированный код в репозитории. Если код больше не нужен, удалите его. История изменений сохраняется в системе контроля версий:
// Плохо: закомментированный код
// if (user.isAdmin()) {
// showAdminPanel();
// }
// Хорошо: удалённый код
// Админ-панель показывается только после успешной аутентификации
showAdminPanel();
10. Тестирование
10.1. Структура тестов
Тесты должны следовать соглашению об именовании и структуре:
// Имя тестового класса: <Имя класса>Test
public class OrderServiceTest {
// Имя тестового метода: <метод>_<сценарий>_<результат>
@Test
public void calculateTotal_withDiscount_appliesDiscountCorrectly() {
// arrange
Order order = new Order();
order.addItem(new OrderItem(product1, 2));
order.addItem(new OrderItem(product2, 1));
order.setDiscountCode("SUMMER20");
// act
BigDecimal total = order.calculateTotal();
// assert
assertEquals(new BigDecimal("240.00"), total);
}
}
10.2. Принцип AAA (Arrange-Act-Assert)
Каждый тест должен быть разделён на три части:
- Arrange — подготовка данных и зависимостей
- Act — выполнение тестируемого метода
- Assert — проверка результатов
@Test
public void processPayment_validCard_chargesAmount() {
// arrange
PaymentService paymentService = new PaymentService(gatewayMock);
Payment payment = new Payment("4111111111111111", BigDecimal.valueOf(100.00));
// act
PaymentResult result = paymentService.process(payment);
// assert
assertTrue(result.isSuccess());
assertEquals("Payment approved", result.getMessage());
verify(gatewayMock).charge(eq(payment.getAmount()));
}
10.3. Тестирование граничных значений
Всегда тестируйте граничные значения и крайние случаи:
@Test
public void withdraw_amountEqualToBalance_succeeds() {
Account account = new Account(BigDecimal.valueOf(1000.00));
account.withdraw(BigDecimal.valueOf(1000.00));
assertEquals(BigDecimal.ZERO, account.getBalance());
}
@Test
public void withdraw_amountGreaterThanBalance_throwsException() {
Account account = new Account(BigDecimal.valueOf(1000.00));
assertThrows(InsufficientFundsException.class, () -> {
account.withdraw(BigDecimal.valueOf(1000.01));
});
}
@Test
public void withdraw_negativeAmount_throwsException() {
Account account = new Account(BigDecimal.valueOf(1000.00));
assertThrows(IllegalArgumentException.class, () -> {
account.withdraw(BigDecimal.valueOf(-100.00));
});
}
10.4. Mock-объекты и зависимости
Используйте фреймворки для создания моков (Mockito, EasyMock) для изолированного тестирования:
@ExtendWith(MockitoExtension.class)
public class OrderServiceTest {
@Mock
private UserRepository userRepository;
@Mock
private OrderRepository orderRepository;
@Mock
private NotificationService notificationService;
@InjectMocks
private OrderService orderService;
@Test
public void placeOrder_validOrder_savesAndNotifies() {
// arrange
User user = new User(1L, "user@example.com");
Order order = new Order(user, items);
when(userRepository.findById(1L)).thenReturn(Optional.of(user));
doNothing().when(notificationService).sendOrderConfirmation(any());
// act
Order result = orderService.placeOrder(order);
// assert
assertNotNull(result.getId());
verify(orderRepository).save(order);
verify(notificationService).sendOrderConfirmation(order);
}
}
11. Производительность и оптимизация
11.1. Избегайте преждевременной оптимизации
Пишите чистый и понятный код в первую очередь. Оптимизируйте только после профилирования и выявления реальных узких мест:
// Плохо: преждевременная оптимизация
public List<User> findActiveUsers() {
List<User> result = new ArrayList<>(userRepository.countActive());
for (int i = 0; i < userRepository.countActive(); i++) {
result.add(userRepository.findActive().get(i));
}
return result;
}
// Хорошо: читаемый код, оптимизация при необходимости
public List<User> findActiveUsers() {
return userRepository.findActive();
}
11.2. Эффективная работа со строками
Используйте StringBuilder для конкатенации строк в циклах:
// Плохо: создание множества промежуточных объектов String
public String buildReport(List<Order> orders) {
String result = "";
for (Order order : orders) {
result += order.getId() + ": " + order.getTotal() + "\n";
}
return result;
}
// Хорошо: эффективная конкатенация
public String buildReport(List<Order> orders) {
StringBuilder sb = new StringBuilder();
for (Order order : orders) {
sb.append(order.getId())
.append(": ")
.append(order.getTotal())
.append("\n");
}
return sb.toString();
}
11.3. Кэширование результатов
Кэшируйте результаты дорогих операций, когда это уместно:
public class ExchangeRateService {
private final Cache<String, BigDecimal> rateCache =
CacheBuilder.newBuilder()
.maximumSize(1000)
.expireAfterWrite(1, TimeUnit.HOURS)
.build();
public BigDecimal getRate(String fromCurrency, String toCurrency) {
String key = fromCurrency + "_" + toCurrency;
return rateCache.get(key, () -> fetchRateFromExternalApi(fromCurrency, toCurrency));
}
private BigDecimal fetchRateFromExternalApi(String from, String to) {
// Вызов внешнего API
return externalApi.getExchangeRate(from, to);
}
}
11.4. Ленивая инициализация
Используйте ленивую инициализацию для тяжёлых объектов, которые могут не понадобиться:
public class ReportGenerator {
private volatile ReportTemplate template;
private ReportTemplate getTemplate() {
ReportTemplate result = template;
if (result == null) {
synchronized (this) {
result = template;
if (result == null) {
template = result = loadTemplate();
}
}
}
return result;
}
private ReportTemplate loadTemplate() {
// Загрузка шаблона из файла или базы данных
return templateRepository.loadDefault();
}
}
12. Безопасность
12.1. Валидация входных данных
Всегда проверяйте входные данные на стороне сервера, даже если валидация есть на клиенте:
public class UserController {
public ResponseEntity<User> createUser(@RequestBody UserRequest request) {
// Валидация
if (request.getEmail() == null || !isValidEmail(request.getEmail())) {
return ResponseEntity.badRequest().build();
}
if (request.getPassword() == null || request.getPassword().length() < 8) {
return ResponseEntity.badRequest().build();
}
// Создание пользователя
User user = userService.register(request.getEmail(), request.getPassword());
return ResponseEntity.ok(user);
}
private boolean isValidEmail(String email) {
return email != null && email.matches("^[A-Za-z0-9+_.-]+@(.+)$");
}
}
12.2. Защита от SQL-инъекций
Всегда используйте параметризованные запросы:
// Плохо: уязвимость к SQL-инъекции
public User findByEmail(String email) {
String query = "SELECT * FROM users WHERE email = '" + email + "'";
return jdbcTemplate.queryForObject(query, User.class);
}
// Хорошо: параметризованный запрос
public User findByEmail(String email) {
String query = "SELECT * FROM users WHERE email = ?";
return jdbcTemplate.queryForObject(query, new Object[]{email}, User.class);
}
// Ещё лучше: использование JPA
public Optional<User> findByEmail(String email) {
return userRepository.findByEmail(email);
}
12.3. Хеширование паролей
Никогда не храните пароли в открытом виде:
@Service
public class UserService {
private final PasswordEncoder passwordEncoder;
public User register(String email, String password) {
// Хеширование пароля
String hashedPassword = passwordEncoder.encode(password);
User user = new User();
user.setEmail(email);
user.setPasswordHash(hashedPassword);
user.setCreatedAt(LocalDateTime.now());
return userRepository.save(user);
}
public boolean authenticate(String email, String password) {
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new AuthenticationException("User not found"));
// Сравнение хешей
return passwordEncoder.matches(password, user.getPasswordHash());
}
}
12.4. Защита от XSS
Экранируйте пользовательский ввод при выводе в HTML:
@Controller
public class CommentController {
@GetMapping("/comments")
public String showComments(Model model) {
List<Comment> comments = commentRepository.findAll();
// Экранирование текста комментариев
List<Comment> safeComments = comments.stream()
.map(comment -> new Comment(
comment.getId(),
escapeHtml(comment.getAuthor()),
escapeHtml(comment.getText())
))
.collect(Collectors.toList());
model.addAttribute("comments", safeComments);
return "comments";
}
private String escapeHtml(String input) {
if (input == null) return null;
return input.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace("\"", """)
.replace("'", "'");
}
}
13. Логирование
13.1. Уровни логирования
Используйте правильные уровни логирования для разных типов сообщений:
@Service
public class OrderService {
private static final Logger logger = LoggerFactory.getLogger(OrderService.class);
public Order createOrder(OrderRequest request) {
logger.debug("Создание заказа: userId={}, items={}",
request.getUserId(), request.getItems().size());
try {
Order order = orderRepository.save(convert(request));
logger.info("Заказ создан: orderId={}", order.getId());
return order;
} catch (ValidationException e) {
logger.warn("Ошибка валидации при создании заказа: userId={}, error={}",
request.getUserId(), e.getMessage());
throw e;
} catch (Exception e) {
logger.error("Ошибка создания заказа: userId={}", request.getUserId(), e);
throw new OrderProcessingException("Failed to create order", e);
}
}
}
13.2. Структурированное логирование
Используйте структурированный формат логов для упрощения анализа:
public class StructuredLogger {
private final ObjectMapper objectMapper = new ObjectMapper();
public void logOrderEvent(Order order, String eventType) {
Map<String, Object> logEntry = new HashMap<>();
logEntry.put("eventType", eventType);
logEntry.put("timestamp", Instant.now().toString());
logEntry.put("orderId", order.getId());
logEntry.put("userId", order.getUserId());
logEntry.put("totalAmount", order.getTotalAmount());
logEntry.put("status", order.getStatus());
logger.info(objectMapper.writeValueAsString(logEntry));
}
}
14. Обработка дат и времени
14.1. Использование java.time
Всегда используйте классы из пакета java.time вместо устаревших Date и Calendar:
// Плохо: устаревшие классы
public class OldEvent {
private Date startDate;
private Date endDate;
}
// Хорошо: современные классы
public class Event {
private LocalDateTime startDateTime;
private LocalDateTime endDateTime;
private ZoneId timeZone;
public boolean isHappeningNow() {
LocalDateTime now = LocalDateTime.now(timeZone);
return !now.isBefore(startDateTime) && now.isBefore(endDateTime);
}
public Duration getDuration() {
return Duration.between(startDateTime, endDateTime);
}
}
14.2. Работа с часовыми поясами
Явно указывайте часовые пояса при работе с датами:
public class SchedulerService {
private static final ZoneId DEFAULT_ZONE = ZoneId.of("Europe/Moscow");
public void scheduleNotification(User user, LocalDateTime localTime) {
// Преобразование локального времени пользователя в UTC
ZonedDateTime userDateTime = localTime.atZone(user.getTimeZone());
ZonedDateTime utcDateTime = userDateTime.withZoneSameInstant(ZoneOffset.UTC);
notificationScheduler.schedule(utcDateTime.toInstant(), user.getId());
}
public String formatForUser(LocalDateTime utcTime, User user) {
ZonedDateTime userTime = utcTime.atZone(ZoneOffset.UTC)
.withZoneSameInstant(user.getTimeZone());
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm");
return userTime.format(formatter);
}
}
15. Работа с файлами и ресурсами
15.1. Потоковая обработка больших файлов
Используйте потоковую обработку для больших файлов, чтобы избежать переполнения памяти:
public class FileProcessor {
public void processLargeFile(Path filePath) throws IOException {
try (BufferedReader reader = Files.newBufferedReader(filePath)) {
String line;
while ((line = reader.readLine()) != null) {
processLine(line);
}
}
}
public void writeLargeFile(Path filePath, List<String> lines) throws IOException {
try (BufferedWriter writer = Files.newBufferedWriter(filePath)) {
for (String line : lines) {
writer.write(line);
writer.newLine();
}
}
}
public long countLines(Path filePath) throws IOException {
try (Stream<String> lines = Files.lines(filePath)) {
return lines.count();
}
}
}
15.2. Работа с ресурсами классов
Используйте ClassLoader для доступа к ресурсам:
public class ResourceLoader {
public String loadTemplate(String templateName) throws IOException {
URL resourceUrl = getClass().getClassLoader()
.getResource("templates/" + templateName + ".html");
if (resourceUrl == null) {
throw new FileNotFoundException("Template not found: " + templateName);
}
return Files.readString(Path.of(resourceUrl.toURI()));
}
public Properties loadConfiguration() throws IOException {
Properties properties = new Properties();
try (InputStream input = getClass().getClassLoader()
.getResourceAsStream("application.properties")) {
if (input == null) {
throw new FileNotFoundException("Configuration file not found");
}
properties.load(input);
}
return properties;
}
}
16. Многопоточность и конкурентность
16.1. Синхронизация и блокировки
Используйте современные механизмы синхронизации вместо ключевого слова synchronized:
public class CounterService {
// Плохо: блокировка на уровне метода
private int counter = 0;
public synchronized void increment() {
counter++;
}
// Хорошо: использование атомарных операций
private final AtomicInteger atomicCounter = new AtomicInteger(0);
public void incrementAtomic() {
atomicCounter.incrementAndGet();
}
// Ещё лучше: использование пула потоков
private final ExecutorService executor = Executors.newFixedThreadPool(10);
public CompletableFuture<Integer> asyncIncrement() {
return CompletableFuture.supplyAsync(() -> {
return atomicCounter.incrementAndGet();
}, executor);
}
}
16.2. Потокобезопасные коллекции
Используйте потокобезопасные коллекции из пакета java.util.concurrent:
public class CacheService {
// Плохо: синхронизированная коллекция
private final Map<String, Object> syncMap = Collections.synchronizedMap(new HashMap<>());
// Хорошо: конкурентная коллекция
private final ConcurrentHashMap<String, Object> cache = new ConcurrentHashMap<>();
public Object get(String key) {
return cache.get(key);
}
public Object computeIfAbsent(String key, Function<String, Object> mappingFunction) {
return cache.computeIfAbsent(key, mappingFunction);
}
public void put(String key, Object value) {
cache.put(key, value);
}
}
16.3. CompletableFuture для асинхронных операций
Используйте CompletableFuture для композиции асинхронных операций:
public class PaymentProcessor {
public CompletableFuture<PaymentResult> processPaymentAsync(PaymentRequest request) {
return CompletableFuture.supplyAsync(() -> validateRequest(request))
.thenCompose(validatedRequest -> authorizePayment(validatedRequest))
.thenCompose(authorization -> capturePayment(authorization))
.thenApply(captureResult -> buildPaymentResult(captureResult))
.exceptionally(ex -> handlePaymentError(ex, request));
}
private PaymentRequest validateRequest(PaymentRequest request) {
if (request.getAmount().compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException("Amount must be positive");
}
return request;
}
private CompletableFuture<Authorization> authorizePayment(PaymentRequest request) {
return paymentGateway.authorize(request);
}
private CompletableFuture<CaptureResult> capturePayment(Authorization auth) {
return paymentGateway.capture(auth);
}
}
17. Ошибки и анти-паттерны
17.1. Избегайте слишком больших классов
Классы должны быть компактными и сфокусированными на одной ответственности:
// Плохо: класс делает слишком много
public class UserService {
// 2000+ строк кода
// Работа с пользователями, уведомлениями, отчётами, интеграциями...
}
// Хорошо: разделение ответственности
public class UserService { /* только работа с пользователями */ }
public class NotificationService { /* только уведомления */ }
public class ReportService { /* только отчёты */ }
17.2. Избегайте глубокой вложенности
Глубокая вложенность усложняет чтение кода:
// Плохо: глубокая вложенность
public void processOrder(Order order) {
if (order != null) {
if (order.getItems() != null && !order.getItems().isEmpty()) {
if (order.getCustomer() != null) {
if (order.getCustomer().isActive()) {
// 5 уровней вложенности...
}
}
}
}
}
// Хорошо: ранний выход
public void processOrder(Order order) {
if (order == null) {
throw new IllegalArgumentException("Order cannot be null");
}
if (order.getItems() == null || order.getItems().isEmpty()) {
throw new OrderValidationException("Order must have items");
}
if (order.getCustomer() == null) {
throw new OrderValidationException("Customer is required");
}
if (!order.getCustomer().isActive()) {
throw new OrderValidationException("Customer must be active");
}
// Основная логика
}
17.3. Избегайте магических чисел и строк
Выносите константы в отдельные поля:
// Плохо: магические значения
public boolean isAdult(int age) {
return age >= 18;
}
public void processOrder(Order order) {
if (order.getStatus().equals("COMPLETED")) {
// ...
}
}
// Хорошо: именованные константы
private static final int ADULT_AGE_THRESHOLD = 18;
private static final String ORDER_STATUS_COMPLETED = "COMPLETED";
public boolean isAdult(int age) {
return age >= ADULT_AGE_THRESHOLD;
}
public void processOrder(Order order) {
if (order.getStatus().equals(ORDER_STATUS_COMPLETED)) {
// ...
}
}
// Ещё лучше: перечисления
public enum OrderStatus {
PENDING, PROCESSING, COMPLETED, CANCELLED
}
public void processOrder(Order order) {
if (order.getStatus() == OrderStatus.COMPLETED) {
// ...
}
}
17.4. Избегайте длинных методов
Разбивайте длинные методы на более мелкие:
// Плохо: метод на 100+ строк
public void generateReport() {
// Подготовка данных
// Форматирование
// Валидация
// Сохранение
// Отправка
// Логирование
// ...
}
// Хорошо: декомпозиция
public void generateReport() {
ReportData data = prepareReportData();
String content = formatReport(data);
validateReport(content);
saveReport(content);
sendReportNotification();
logReportGeneration();
}
18. Архитектурные подходы и паттерны проектирования
18.1. Слоистая архитектура
Слоистая архитектура разделяет приложение на горизонтальные слои с чёткими границами ответственности. Каждый слой зависит только от слоя ниже:
┌─────────────────────────────────────────┐
│ Презентационный слой (Presentation) │ ← HTTP-контроллеры, DTO
├─────────────────────────────────────────┤
│ Слой приложения (Application) │ ← Сервисы, команды, сценарии
├─────────────────────────────────────────┤
│ Доменный слой (Domain) │ ← Сущности, агрегаты, события
├─────────────────────────────────────────┤
│ Инфраструктурный слой (Infrastructure) │ ← Репозитории, внешние сервисы
└─────────────────────────────────────────┘
Презентационный слой преобразует входящие запросы в команды приложения и результаты в ответы клиенту. Слой приложения координирует выполнение бизнес-сценариев, используя доменные объекты. Доменный слой содержит чистую бизнес-логику без зависимостей от фреймворков. Инфраструктурный слой реализует технические детали: работу с базой данных, внешними API, очередями сообщений.
18.2. Чистая архитектура и зависимости
Чистая архитектура организует код вокруг бизнес-логики, а не вокруг фреймворков. Зависимости направлены внутрь к домену:
┌──────────────────┐
│ Сущности │ ← Чистые объекты бизнес-логики
└────────┬─────────┘
│
┌────────▼─────────┐
│ Сценарии │ ← Используют сущности
└────────┬─────────┘
│
┌────────▼─────────┐
│ Интерфейсы │ ← Адаптеры, контроллеры
└────────┬─────────┘
│
┌────────▼─────────┐
│ Фреймворки │ ← Spring, JPA, внешние библиотеки
└──────────────────┘
Доменные объекты не содержат аннотаций фреймворков. Аннотации @Entity, @Table размещаются в инфраструктурных классах-обёртках или в отдельных классах отображения (DTO/DAO). Такой подход позволяет легко заменять фреймворки и тестировать бизнес-логику без запуска контейнера.
18.3. Агрегаты и границы транзакций
Агрегат представляет собой группу связанных объектов, которые обрабатываются как единое целое. Корень агрегата обеспечивает целостность всех объектов внутри границы:
public class Order {
private final OrderId id;
private final CustomerId customerId;
private final List<OrderItem> items;
private OrderStatus status;
public void addItem(Product product, int quantity) {
validateCanModify();
items.add(new OrderItem(product.getId(), product.getPrice(), quantity));
}
public void removeItem(OrderItemId itemId) {
validateCanModify();
items.removeIf(item -> item.getId().equals(itemId));
}
public void submit() {
validateCanSubmit();
this.status = OrderStatus.SUBMITTED;
registerEvent(new OrderSubmittedEvent(id, customerId, calculateTotal()));
}
private void validateCanModify() {
if (status != OrderStatus.DRAFT) {
throw new OrderModificationException("Cannot modify submitted order");
}
}
}
Репозиторий работает только с корнем агрегата. Все изменения внутри агрегата происходят в рамках одной транзакции. Граница агрегата определяет, какие объекты загружаются и сохраняются вместе.
18.4. Событийно-ориентированная архитектура
События фиксируют факты произошедших изменений в системе. Доменные события генерируются агрегатами и обрабатываются другими компонентами:
// Доменное событие
public record OrderSubmittedEvent(
OrderId orderId,
CustomerId customerId,
BigDecimal totalAmount,
Instant occurredAt
) implements DomainEvent { }
// Генерация события в агрегате
public class Order {
private final List<DomainEvent> events = new ArrayList<>();
public void submit() {
// бизнес-логика
events.add(new OrderSubmittedEvent(id, customerId, total, Instant.now()));
}
public List<DomainEvent> getUncommittedEvents() {
return new ArrayList<>(events);
}
public void markEventsAsCommitted() {
events.clear();
}
}
// Обработчик события
@Component
public class OrderNotificationHandler
implements DomainEventHandler<OrderSubmittedEvent> {
private final EmailService emailService;
@Override
public void handle(OrderSubmittedEvent event) {
emailService.sendOrderConfirmation(
event.customerId(),
event.orderId(),
event.totalAmount()
);
}
}
События позволяют декомпозировать систему на слабо связанные компоненты. Изменения в одном модуле автоматически распространяются на другие без прямых зависимостей.
19. Работа с базами данных
19.1. Репозитории и спецификации
Репозиторий предоставляет абстракцию над хранилищем данных. Интерфейс репозитория размещается в доменном слое, реализация — в инфраструктурном:
// Доменный интерфейс
public interface OrderRepository {
Order findById(OrderId id);
List<Order> findByCustomer(CustomerId customerId);
List<Order> findPendingOrders();
void save(Order order);
void delete(OrderId id);
}
// Инфраструктурная реализация
@Repository
public class JpaOrderRepository implements OrderRepository {
private final JpaRepository<OrderEntity, Long> jpaRepository;
@Override
public Order findById(OrderId id) {
return jpaRepository.findById(id.value())
.map(this::toDomain)
.orElse(null);
}
@Override
public void save(Order order) {
OrderEntity entity = toEntity(order);
jpaRepository.save(entity);
order.markEventsAsCommitted();
}
private Order toDomain(OrderEntity entity) {
// преобразование entity → domain object
}
private OrderEntity toEntity(Order order) {
// преобразование domain object → entity
}
}
Паттерн «Спецификация» позволяет создавать повторно используемые условия запросов:
public interface Specification<T> {
Predicate toPredicate(Root<T> root, CriteriaQuery<?> query, CriteriaBuilder cb);
}
public class OrderSpecifications {
public static Specification<OrderEntity> byCustomer(CustomerId customerId) {
return (root, query, cb) ->
cb.equal(root.get("customerId"), customerId.value());
}
public static Specification<OrderEntity> pending() {
return (root, query, cb) ->
cb.equal(root.get("status"), OrderStatus.PENDING.name());
}
public static Specification<OrderEntity> withAmountGreaterThan(BigDecimal amount) {
return (root, query, cb) ->
cb.greaterThan(root.get("totalAmount"), amount);
}
}
// Использование
List<Order> orders = orderRepository.findAll(
Specification.where(OrderSpecifications.byCustomer(customerId))
.and(OrderSpecifications.pending())
.and(OrderSpecifications.withAmountGreaterThan(minAmount))
);
19.2. Отложенные загрузки и графы выборки
Явно управляйте загрузкой связанных сущностей для предотвращения проблемы N+1:
// Плохо: неявная загрузка в цикле
List<Order> orders = orderRepository.findAll();
for (Order order : orders) {
// Каждый вызов getItems() выполняет отдельный SQL-запрос
List<OrderItem> items = order.getItems();
}
// Хорошо: явная загрузка графа
@EntityGraph(attributePaths = {"items", "customer"})
List<Order> orders = orderRepository.findAllWithItemsAndCustomer();
Определяйте несколько методов репозитория для разных сценариев использования с разными графами загрузки:
public interface OrderRepository extends JpaRepository<OrderEntity, Long> {
@EntityGraph(attributePaths = {"items"})
Optional<OrderEntity> findByIdWithItems(Long id);
@EntityGraph(attributePaths = {"customer", "shippingAddress"})
Optional<OrderEntity> findByIdWithCustomerDetails(Long id);
@EntityGraph(attributePaths = {"items", "customer", "payments"})
Optional<OrderEntity> findByIdWithFullDetails(Long id);
}
19.3. Миграции базы данных
Используйте инструменты миграции баз данных для управления схемой:
-- V1__create_users_table.sql
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- V2__add_orders_table.sql
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id),
status VARCHAR(50) NOT NULL,
total_amount NUMERIC(19,2) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- V3__add_index_on_orders_user_id.sql
CREATE INDEX idx_orders_user_id ON orders(user_id);
Каждая миграция имеет уникальный номер версии и описательное имя. Миграции применяются последовательно и являются неизменяемыми после коммита в репозиторий.
20. Инъекция зависимостей и конфигурация
20.1. Конструкторная инъекция
Предпочитайте конструкторную инъекцию зависимостей. Она делает зависимости явными и упрощает тестирование:
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final PaymentService paymentService;
private final NotificationService notificationService;
public OrderService(
OrderRepository orderRepository,
PaymentService paymentService,
NotificationService notificationService
) {
this.orderRepository = Objects.requireNonNull(orderRepository);
this.paymentService = Objects.requireNonNull(paymentService);
this.notificationService = Objects.requireNonNull(notificationService);
}
public Order placeOrder(OrderRequest request) {
// реализация
}
}
Конструкторная инъекция гарантирует, что объект создаётся в полностью инициализированном состоянии. Все обязательные зависимости передаются через конструктор, опциональные — через сеттеры.
20.2. Конфигурационные свойства
Группируйте конфигурационные параметры в типизированные классы:
@ConfigurationProperties(prefix = "app.payment")
@Component
public class PaymentProperties {
private final Gateway gateway = new Gateway();
private final Retry retry = new Retry();
public Gateway getGateway() {
return gateway;
}
public Retry getRetry() {
return retry;
}
public static class Gateway {
private String url;
private String apiKey;
private int connectTimeout = 5000;
private int readTimeout = 10000;
// геттеры и сеттеры
}
public static class Retry {
private int maxAttempts = 3;
private long initialBackoff = 1000;
private double backoffMultiplier = 2.0;
// геттеры и сеттеры
}
}
// Использование
@Service
public class PaymentService {
private final PaymentProperties properties;
public PaymentService(PaymentProperties properties) {
this.properties = properties;
}
public void processPayment(Payment payment) {
String gatewayUrl = properties.getGateway().getUrl();
int maxRetries = properties.getRetry().getMaxAttempts();
// ...
}
}
Типизированные свойства обеспечивают безопасность на этапе компиляции и упрощают навигацию по конфигурации в IDE.
20.3. Профили окружений
Используйте профили для разделения конфигурации разных окружений:
src/main/resources/
├── application.properties # общая конфигурация
├── application-dev.properties # разработка
├── application-test.properties # тестирование
├── application-stage.properties # предпродакшен
└── application-prod.properties # продакшен
Активация профиля происходит через переменную окружения или аргумент запуска:
# Активация профиля dev
java -jar application.jar --spring.profiles.active=dev
# Переменная окружения
export SPRING_PROFILES_ACTIVE=prod
java -jar application.jar
Конфигурация для продакшена никогда не должна содержать учётные данные в открытом виде. Используйте секреты через переменные окружения или менеджеры секретов.
21. Версионирование API
21.1. Стратегии версионирования
Выберите одну стратегию версионирования и применяйте её последовательно:
- Версия в пути URL:
/api/v1/orders,/api/v2/orders - Версия в заголовке:
Accept: application/vnd.company.v2+json - Версия в параметре запроса:
/api/orders?version=2(не рекомендуется)
Предпочтительный подход — версия в пути URL. Он явно виден в запросах, легко маршрутизируется и поддерживается большинством инструментов.
@RestController
@RequestMapping("/api/v1/orders")
public class OrderControllerV1 {
@GetMapping("/{id}")
public OrderResponse getOrder(@PathVariable Long id) {
// реализация V1
}
}
@RestController
@RequestMapping("/api/v2/orders")
public class OrderControllerV2 {
@GetMapping("/{id}")
public OrderResponseV2 getOrder(@PathVariable Long id) {
// реализация V2 с улучшенной структурой ответа
}
}
21.2. Поддержка нескольких версий
Поддерживайте несколько версий API параллельно в течение определённого периода миграции. Удаляйте старые версии только после уведомления клиентов и завершения миграции.
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/api/v1/**")
.addResourceLocations("classpath:/api/v1/");
registry.addResourceHandler("/api/v2/**")
.addResourceLocations("classpath:/api/v2/");
}
}
В документации каждой версии API указывайте дату прекращения поддержки и рекомендации по миграции на новую версию.
22. Валидация данных
22.1. Аннотации валидации Jakarta Bean Validation
Используйте стандартные аннотации для декларативной валидации:
public class UserRegistrationRequest {
@NotBlank(message = "Email required")
@Email(message = "Invalid email format")
@Size(max = 255)
private String email;
@NotBlank(message = "Password required")
@Size(min = 8, max = 128, message = "Password must be 8-128 characters")
private String password;
@Pattern(regexp = "^[A-Za-zа-яА-ЯёЁ\\s-]+$", message = "Invalid name format")
@Size(min = 2, max = 100)
private String firstName;
@Size(max = 100)
private String lastName;
@NotNull(message = "Birth date required")
@Past(message = "Birth date must be in the past")
private LocalDate birthDate;
// геттеры и сеттеры
}
@RestController
public class UserController {
@PostMapping("/users")
public ResponseEntity<UserResponse> register(
@Valid @RequestBody UserRegistrationRequest request
) {
// обработка валидного запроса
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidationErrors(
MethodArgumentNotValidException ex
) {
List<String> errors = ex.getBindingResult()
.getFieldErrors()
.stream()
.map(error -> error.getField() + ": " + error.getDefaultMessage())
.collect(Collectors.toList());
return ResponseEntity.badRequest()
.body(new ErrorResponse("VALIDATION_ERROR", errors));
}
}
22.2. Кастомные валидаторы
Создавайте кастомные аннотации для сложных правил валидации:
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PasswordStrengthValidator.class)
public @interface StrongPassword {
String message() default "Password must contain uppercase, lowercase, digit and special character";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
public class PasswordStrengthValidator
implements ConstraintValidator<StrongPassword, String> {
private static final Pattern UPPERCASE = Pattern.compile("[A-Z]");
private static final Pattern LOWERCASE = Pattern.compile("[a-z]");
private static final Pattern DIGIT = Pattern.compile("[0-9]");
private static final Pattern SPECIAL = Pattern.compile("[!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>\\/?]");
@Override
public boolean isValid(String password, ConstraintValidatorContext context) {
if (password == null || password.isEmpty()) {
return true; // null проверяется аннотацией @NotBlank
}
return UPPERCASE.matcher(password).find()
&& LOWERCASE.matcher(password).find()
&& DIGIT.matcher(password).find()
&& SPECIAL.matcher(password).find();
}
}
// Использование
public class ChangePasswordRequest {
@NotBlank
@StrongPassword
private String newPassword;
// ...
}
23. Мониторинг и метрики
23.1. Сбор метрик приложения
Интегрируйте Micrometer для сбора метрик:
@Service
public class OrderService {
private final Counter ordersCreatedCounter;
private final Timer orderProcessingTimer;
private final Gauge activeOrdersGauge;
public OrderService(MeterRegistry meterRegistry) {
this.ordersCreatedCounter = Counter.builder("orders.created.total")
.description("Total number of orders created")
.tag("status", "success")
.register(meterRegistry);
this.orderProcessingTimer = Timer.builder("orders.processing.duration")
.description("Time taken to process orders")
.register(meterRegistry);
this.activeOrdersGauge = Gauge.builder("orders.active.current",
orderRepository, repo -> repo.countActiveOrders())
.description("Current number of active orders")
.register(meterRegistry);
}
public Order createOrder(OrderRequest request) {
return orderProcessingTimer.record(() -> {
Order order = doCreateOrder(request);
ordersCreatedCounter.increment();
return order;
});
}
}
Метрики экспортируются в системы мониторинга: Prometheus, Graphite, Datadog. Настройте алерты на критические показатели: ошибки, задержки, использование ресурсов.
23.2. Распределённое трассирование
Добавьте поддержку распределённого трассирования для микросервисных архитектур:
@RestController
@RequestMapping("/orders")
public class OrderController {
private final Tracer tracer;
private final OrderService orderService;
@PostMapping
public ResponseEntity<OrderResponse> createOrder(@RequestBody OrderRequest request) {
Span span = tracer.nextSpan().name("createOrder").start();
try (Tracer.SpanInScope ws = tracer.withSpanInScope(span)) {
span.tag("order.customerId", request.getCustomerId().toString());
span.tag("order.itemsCount", String.valueOf(request.getItems().size()));
Order order = orderService.createOrder(request);
span.tag("order.id", order.getId().toString());
return ResponseEntity.ok(toResponse(order));
} catch (Exception e) {
span.error(e);
throw e;
} finally {
span.finish();
}
}
}
Каждый запрос получает уникальный trace ID, который передаётся между сервисами через заголовки HTTP. Это позволяет отслеживать полный путь запроса через систему.
24. Локализация и интернационализация
24.1. Файлы свойств для разных языков
Организуйте файлы локализации по языковым тегам:
src/main/resources/
├── messages.properties # fallback
├── messages_en.properties # английский
├── messages_ru.properties # русский
├── messages_fr.properties # французский
└── validation-messages_ru.properties
Содержимое файла messages_ru.properties:
order.created=Заказ #{0} успешно создан
order.not.found=Заказ с идентификатором {0} не найден
payment.successful=Платёж на сумму {0} успешно обработан
payment.failed=Ошибка обработки платежа: {0}
24.2. Использование MessageLookup
Инжектируйте MessageSource для получения локализованных сообщений:
@Service
public class NotificationService {
private final MessageSource messageSource;
public NotificationService(MessageSource messageSource) {
this.messageSource = messageSource;
}
public String getOrderCreatedMessage(Long orderId, Locale locale) {
return messageSource.getMessage(
"order.created",
new Object[]{orderId},
"Order #{0} created successfully", // сообщение по умолчанию
locale
);
}
public String getPaymentFailedMessage(String reason, Locale locale) {
return messageSource.getMessage(
"payment.failed",
new Object[]{reason},
"Payment failed: {0}",
locale
);
}
}
Определяйте локаль пользователя на основе заголовка Accept-Language или сохранённых предпочтений в профиле.
25. Практики развёртывания и эксплуатации
25.1. Health checks
Реализуйте эндпоинты проверки работоспособности:
@RestController
@RequestMapping("/actuator")
public class HealthController {
private final DataSource dataSource;
private final CacheManager cacheManager;
private final ExternalService externalService;
@GetMapping("/health")
public ResponseEntity<HealthResponse> health() {
HealthStatus dbStatus = checkDatabase();
HealthStatus cacheStatus = checkCache();
HealthStatus externalStatus = checkExternalService();
HealthStatus overall = determineOverallStatus(dbStatus, cacheStatus, externalStatus);
HealthResponse response = new HealthResponse(
overall,
Map.of(
"database", dbStatus,
"cache", cacheStatus,
"externalService", externalStatus
),
Instant.now()
);
HttpStatus status = overall == HealthStatus.UP ? HttpStatus.OK : HttpStatus.SERVICE_UNAVAILABLE;
return ResponseEntity.status(status).body(response);
}
private HealthStatus checkDatabase() {
try {
try (Connection conn = dataSource.getConnection();
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery("SELECT 1")) {
return HealthStatus.UP;
}
} catch (Exception e) {
return HealthStatus.DOWN;
}
}
}
Health checks используются оркестраторами (Kubernetes, Docker Swarm) для определения готовности пода принимать трафик и его живости.
25.2. Graceful shutdown
Реализуйте корректное завершение работы приложения:
@Component
public class GracefulShutdown implements DisposableBean {
private static final Logger logger = LoggerFactory.getLogger(GracefulShutdown.class);
private final ExecutorService taskExecutor;
private final MessageListenerContainer messageListener;
private final long shutdownTimeoutMillis = 30_000;
@Override
public void destroy() {
logger.info("Начало корректного завершения работы");
// Остановка приёма новых сообщений
if (messageListener != null) {
messageListener.stop();
logger.info("Приём сообщений остановлен");
}
// Завершение выполняющихся задач
taskExecutor.shutdown();
try {
if (!taskExecutor.awaitTermination(shutdownTimeoutMillis, TimeUnit.MILLISECONDS)) {
logger.warn("Не все задачи завершились за {} мс, принудительная остановка", shutdownTimeoutMillis);
List<Runnable> dropped = taskExecutor.shutdownNow();
logger.warn("Принудительно остановлено {} задач", dropped.size());
} else {
logger.info("Все задачи успешно завершены");
}
} catch (InterruptedException e) {
logger.warn("Ожидание завершения прервано", e);
taskExecutor.shutdownNow();
Thread.currentThread().interrupt();
}
logger.info("Завершение работы приложения завершено");
}
}
Настройте таймаут завершения в оркестраторе больше, чем время ожидания в приложении. Это гарантирует, что все задачи успеют завершиться до уничтожения контейнера.
26. Работа с датами рождения и персональными данными
26.1. Хранение дат рождения
Храните дату рождения как LocalDate без часового пояса. Возраст рассчитывайте динамически:
public class User {
private final LocalDate birthDate;
public int getAge() {
return Period.between(birthDate, LocalDate.now()).getYears();
}
public boolean isAdult() {
return getAge() >= 18;
}
public boolean hasBirthdayToday() {
LocalDate today = LocalDate.now();
return birthDate.getMonth() == today.getMonth()
&& birthDate.getDayOfMonth() == today.getDayOfMonth();
}
}
Никогда не храните возраст как отдельное поле — он устаревает каждый день. Всегда рассчитывайте его на основе даты рождения.
26.2. Защита персональных данных
Применяйте принцип минимальных привилегий к персональным данным:
// Плохо: все поля доступны напрямую
public class User {
private String email;
private String phoneNumber;
private LocalDate birthDate;
private String address;
public String getEmail() { return email; }
public String getPhoneNumber() { return phoneNumber; }
public LocalDate getBirthDate() { return birthDate; }
public String getAddress() { return address; }
}
// Хорошо: контролируемый доступ
public class User {
private final String email;
private final String phoneNumberHash; // хеш для поиска
private final LocalDate birthDate;
private final String address;
// Публичный метод для отображения
public String getObfuscatedEmail() {
int atIndex = email.indexOf('@');
if (atIndex <= 1) return email;
String prefix = email.substring(0, 2) + "***";
return prefix + email.substring(atIndex);
}
// Метод только для аутентифицированных пользователей
public String getEmailForOwner() {
return email;
}
// Возраст вместо даты рождения для внешних сервисов
public int getAge() {
return Period.between(birthDate, LocalDate.now()).getYears();
}
// Полная дата рождения только для внутренних операций
String getBirthDateInternal() {
return birthDate.toString();
}
}
Шифруйте чувствительные данные на уровне базы данных или приложения. Хешируйте данные, используемые только для поиска (номера телефонов). Ограничивайте доступ к персональным данным через права ролей и аудит операций.
27. Инструменты и автоматизация
27.1. Форматирование кода с Spotless
Настройте Spotless для автоматического форматирования:
plugins {
id 'com.diffplug.spotless' version '6.25.0'
}
spotless {
java {
target fileTree('.') {
include '**/*.java'
exclude '**/build/**', '**/.gradle/**'
}
toggleOffOn()
importOrder()
removeUnusedImports()
eclipse().configFile('spotless/eclipse-format.xml')
indentWithSpaces(4)
endWithNewline()
}
}
Добавьте задачу форматирования в процесс сборки:
check.dependsOn spotlessCheck
spotlessApply.dependsOn compileJava
Разработчики запускают ./gradlew spotlessApply перед коммитом. Внедрите проверку форматирования в пайплайн CI — сборка падает при несоответствии стилю.
27.2. Статический анализ с SonarQube
Интегрируйте SonarQube для анализа качества кода:
plugins {
id 'org.sonarqube' version '4.4.1.3373'
}
sonarqube {
properties {
property 'sonar.projectKey', 'com.example:order-service'
property 'sonar.host.url', 'https://sonar.example.com'
property 'sonar.login', System.getenv('SONAR_TOKEN')
property 'sonar.coverage.jacoco.xmlReportPaths',
"${buildDir}/reports/jacoco/test/jacocoTestReport.xml"
property 'sonar.java.binaries', "${buildDir}/classes"
property 'sonar.sources', 'src/main/java'
property 'sonar.tests', 'src/test/java'
}
}
Настройте Quality Gates в SonarQube: максимальное количество багов, уязвимостей, code smells. Сборка ветки main должна проходить Quality Gate с оценкой A.
27.3. Генерация документации API
Используйте SpringDoc OpenAPI для автоматической генерации спецификации:
@RestController
@RequestMapping("/api/v1/orders")
@Tag(name = "Orders", description = "Order management operations")
public class OrderController {
@Operation(summary = "Create a new order",
description = "Creates an order for authenticated customer")
@ApiResponses(value = {
@ApiResponse(responseCode = "201", description = "Order created",
content = @Content(mediaType = "application/json",
schema = @Schema(implementation = OrderResponse.class))),
@ApiResponse(responseCode = "400", description = "Invalid request",
content = @Content(mediaType = "application/json",
schema = @Schema(implementation = ErrorResponse.class))),
@ApiResponse(responseCode = "401", description = "Unauthorized")
})
@PostMapping
public ResponseEntity<OrderResponse> createOrder(
@io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "Order creation request", required = true,
content = @Content(schema = @Schema(implementation = OrderRequest.class))
)
@Valid @RequestBody OrderRequest request
) {
Order order = orderService.createOrder(request);
URI location = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(order.getId())
.toUri();
return ResponseEntity.created(location).body(toResponse(order));
}
}
Документация доступна по эндпоинту /v3/api-docs в формате OpenAPI 3.0 и через веб-интерфейс Swagger UI по пути /swagger-ui.html.